Kademlia
Kademlia(IPTPS 2002)是这批的最后一篇,也是最短、机制最统一的一篇。它的做法是把"距离"这一个选择换掉 —— 距离就是两个标识符按位异或(XOR)后的整数值 —— 然后路由表结构、查找的并行性、以及"在线越久越可信"这三件事都能从这一个选择里推出来。
它的三条"第一":
凭借基于 XOR 的度量拓扑,Kademlia 是第一个把可证明的一致性与性能、最小化延迟的路由、以及对称且单向的拓扑结合起来的 P2P 系统。 它引入了并发参数
,让人可以用一个常数倍的带宽换取异步的最低延迟选择跳与无延迟的故障恢复。最后,Kademlia 是第一个利用"节点失败率与在线时长成反比"这一事实的 P2P 系统。
先看清 Chord / Pastry 差在哪
它明确指出了前作的机制缺陷,而这些批评正好是 XOR 要解决的:
Chord 的节点不会从收到的查询里学到有用的路由信息。更糟的是,不对称性导致路由表变得僵硬。 Chord 节点 finger table 的每一项必须存"某个区间之前的那一个精确节点"。任何真正落在这个区间内的节点,都会因为不够靠近区间之前的那些节点而不能用。
Kademlia 相反,可以把查询发给"某个区间内的任意节点",从而让它能基于延迟选路,甚至向若干个同样合适的节点并行、异步地发查询。
第二条批评指向的是它自己那条线的前作:
在已有系统里,Kademlia 最接近 Pastry 的第一阶段 —— 那个阶段(虽然该文没有这样描述)按 Kademlia 的 XOR 度量连续找到距目标 ID 大约一半远的节点。然而在第二阶段,Pastry 把度量换成了 ID 之间的数值差,复制时也用第二套数值差度量。不幸的是,按第二套度量接近的节点按第一套度量可能相当远,这会在特定的节点 ID 值处产生不连续,降低性能,并使最坏情况行为的形式化分析复杂化。
还有一条贯穿全篇的机制对比:Kademlia 从开始到结束只用一个路由算法,而其他系统用一个算法靠近目标、再用另一个算法走最后几跳。
二进制树视角与 XOR 的三条性质
Kademlia 事实上把节点看作一棵二叉树上的叶子,每个节点的位置由它 ID 的最短唯一前缀决定。 对任一节点,把这棵二叉树分成一串逐级更低的、不包含该节点的子树:最高的子树是"不含该节点的、二叉树的那一半",下一个是"剩下的树里不含该节点的、那一半",依此类推。例子里节点 0011 对应的子树前缀是 1、01、000、0010。
协议要保证的性质是:每个节点在它的每一棵子树里都至少知道一个节点(只要那棵子树里有节点)。 有了这条保证,任何节点都能按 ID 定位任何其他节点 —— 查找就是在这串子树里逐级往下走。
距离的定义只有一行:给定两个 160 位标识符
接着三条性质,每一条都立刻被用上:
| 性质 | 内容 | 用途 |
|---|---|---|
| 度量合法性 | 是个(非欧氏的)度量 | |
| 三角不等式 | 由 | |
| 单向性 | 对任一点 | 所有对同一个 key 的查找,无论从哪个节点发起,都沿同一条路径收敛 |
单向性那一条的用处很具体:正因为查找路径唯一,沿查找路径缓存 (key,value) 就能缓解热点。这一点在后面的缓存设计里直接兑现了。
还有一条对称性值得单独记,因为它和单向性是两件事:"像 Pastry 而不像 Chord,XOR 拓扑还是对称的"(
XOR 与二叉树的关系:在一棵满的 160 位 ID 二叉树里,两个 ID 之间距离的大小就是包含它们的最小子树的高度;树不满时,离 ID
k-bucket 与「在线越久越可信」
节点的路由状态叫 k-bucket:对每个
收到任何消息(请求或应答)时的更新规则是这套设计里最值得逐字读的一段:
- 若发送者已在对应 bucket 里 → 把它移到尾部;
- 若不在、且 bucket 未满 → 把新发送者插到尾部;
- 若 bucket 已满 → ping 该 bucket 里最久没见到的那个节点:
- 它不应答 → 把它逐出,新发送者插到尾部;
- 它应答了 → 把它移到尾部,新发送者的联系信息被丢弃。
k-bucket 实际上实现的是一种"最久未见者优先逐出"策略,但有个例外:活着的节点永远不会被移出列表。
这条"偏向老联系人"的规则不是猜的,动机来自 Saroiu 等人采集的 Gnutella 跟踪数据:一个节点已经在线越久,它在下一个小时里继续在线的概率就越高。通过留住最老的活联系人,k-bucket 最大化了它所含节点保持在线这一事件的概率。
附带还有一个安全收益:k-bucket 提供对某些 DoS 攻击的抵抗 —— 你无法通过向系统灌入大量新节点来冲掉节点的路由状态,因为 Kademlia 只会在老节点离开时才插入新节点。
四个 RPC 与 node lookup
协议只有四个 RPC:ping(探活)、store(让某个节点存一个 key,value)、find_node(给一个 160 位 ID,返回它知道的最接近该 ID 的 find_value(行为与 find_node 相同,唯一区别是:若接收方存过这个 key 的值,就直接把值返回)。
一条安全细节:所有 RPC 的接收方都必须回显一个 160 位随机 RPC ID,这对地址伪造提供了一定抵抗;ping 也可以搭在 RPC 应答上捎带,让接收方多一层对发送方网络地址的确信。
node lookup 是最重要的过程(定位距某 ID 最近的
- 发起者从自己最近的非空 k-bucket 里挑
个节点(若该 bucket 不足 项,就取它知道的最接近的 个); - 向这
个节点并行、异步地发 find_node。是系统级并发参数,例如 3。 - 递归步:发起者把
find_node重发给从先前 RPC 里学到的节点 —— 这个递归可以在上一轮个 RPC 还没全部返回时就开始。在它听说过的、离目标最近的 个节点里,挑 个尚未查询过的重发; - 应答慢的节点被移出考虑范围,直到(如果)它们应答为止;
- 若某一轮
find_node没能返回比已见过的最接近者更近的节点,发起者就把find_node重发给"它尚未查询过的那个最近节点" —— 这是防止局部停滞的放宽手段; - 查找在发起者已从它见过的最接近的
个节点都查询并得到应答时终止。
脚注里还有一句很实用的话:bucket 项与
find的应答都可以附上往返时间估计,用来挑选那个节点。
路由表:一棵按需生长的二叉树
路由表就是一棵二叉树,叶子是 k-bucket。每个 k-bucket 里的节点共享 ID 的某个前缀,那个前缀就是它在二叉树中的位置 —— 于是每个 bucket 覆盖 ID 空间的一段,合起来无缝地覆盖整个 160 位空间。树上的节点按需动态分配:
- 初始时路由树只有一个节点 —— 一张覆盖整个 ID 空间的 k-bucket;
- 学到新联系人时插到合适的 bucket:未满就直接插;
- 已满、且该 bucket 的范围包含本节点自己的 ID → 把 bucket 一分为二,旧内容分到两边,重试插入;
- 已满、但范围不含本节点自己的 ID → 新联系人直接被丢弃。
不平衡树那个微妙处
这是 Kademlia 常被讲漏的一点。一个例子很具体:假设节点 000 开头的节点;再假设系统里已经有超过 001 开头的节点。
那么每个以
001开头的节点都会有一张空 k-bucket,本该被插进去,然而 的 bucket 刷新只会通知到其中 个。
解法是:Kademlia 节点会保留某个规模至少为 001 开头的节点都会得知它的存在。这些额外的分裂在路由树的分支上造成的是一点期望常数的规则性破坏。
存储、重发布与缓存
存:定位距 key 最近的 store。
重发布:为保证持久性,节点必须周期性重发布。有两种现象会导致有效 key 查不到:① 最初存下 (key,value) 的那
朴素实现下,存有该 (key,value) 的最多 store。两条优化把它压了下来:
- 收到某个 (key,value) 的
store时,接收方就假定这条 RPC 也发给了另外个最近的节点,于是它在下一个小时内不再重发布它 → 只要各节点的重发布间隔不是精确同步的,每小时就只有同一个 (key,value) 的一个节点在重发布; - 省掉重发布之前的 node lookup:为处理不平衡树,节点会按需分裂 bucket,以保证自己完整知道一个规模至少为
的周围子树。只要在重发布前刷新这棵子树里的所有 k-bucket,它就能自己算出任意 key 的 个最近节点,而且这些 bucket 刷新还能摊到许多 key 的重发布上。这为什么成立可以用两种情况论证(key 落在子树范围内 / key 落在子树外但 本身是最近 个之一)。
新节点加入时要存它该存的那份:已有节点因为完整知道自己的周围子树,所以知道新节点应当存哪些 (key,value);于是任何得知新节点的节点会发 store 把相关数据转过去。为避免重复的 store,一个节点只在"自己的 ID 比别的节点更接近该 key"时才转这份数据。
缓存这一步把单向性直接用上了:一次查找成功后,发起者把 (key,value) 存在它观察到的、离该 key 最近但并没有返回该值的那个节点上。
由于拓扑的单向性,对同一个 key 的后续查找很可能会在到达最近的节点之前先命中缓存。
为防止"过度缓存",有一个很精巧的做法:
把任意节点里一个 (key,value) 的过期时间,设为与"当前节点到 ID 最接近该 key 的那个节点之间的节点数"成指数反比。(脚注补充:这个数目可以从当前节点的 bucket 结构推断出来。)
为什么不用 LRU:简单的 LRU 逐出会给出相似的生存期分布,但缓存大小没有任何自然的取法,因为节点事先并不知道系统会存多少值。
刷新:bucket 通常靠过路流量保持新鲜;为处理"某段 ID 范围没有查找"的病态情形,每个节点会刷新任何它过去一小时内没做过 node lookup 的 bucket —— 刷新就是"在该 bucket 的范围里挑一个随机 ID 做一次节点搜索"。
加入
节点
实现里两条值得记的优化
一、推迟探测,用 replacement cache。 按描述,只要从 bucket 范围内某个未知节点收到消息而 bucket 已满,就要发一次 ping —— 那会产生大量网络消息。实现改法是:把新联系人放进一个"候选替换缓存",等下次真的要查询该 bucket 里的联系人时,再把不应答的逐出、用候选缓存里的条目补上。候选缓存同样按最后见到时间排序,最近见到的替换优先级最高。
配套的两条因为 UDP 而来的处理:丢包常常意味着网络拥塞,所以 Kademlia 会锁住不应答的联系人,并在一个指数增长的退避区间内不再给它发 RPC;而且因为在多数阶段查找只需从
二、一次看
这里有一个很关键的技术论断,说明 XOR 为什么让
虽然基于 XOR 的路由和 Pastry、Tapestry 与 Plaxton 的分布式搜索算法的第一阶段相似,但这三者在推广到
时都变得更复杂。没有 XOR 拓扑,就需要一个额外的算法结构来在"共享同一前缀但下一位 -bit 数字不同"的那些节点中发现目标。三者各自用不同方式解决,各有缺点;它们都需要在大小为 的主表之外再加大小为 的次级路由表。这增加了自举与维护成本、使协议复杂化,而且对 Pastry 与 Tapestry 来说,还使正确性与一致性的形式化分析变得复杂或被阻断了。Plaxton 有证明,但该系统对 P2P 这种高度易故障的环境针对性更弱。
证明要点
要证的是多数操作耗时
- 覆盖距离区间
的 k-bucket,索引为 ; - 一个节点的深度
,其中 是非空 bucket 的最小索引; - 节点
在节点 中的 bucket 高度 = ( 会插入 的 bucket 的索引)−( 中最不重要的空 bucket 的索引)。
因为 ID 是随机选的,严重不均匀的分布不太可能出现,于是任何给定节点的深度以压倒性概率在一个常数与
在"每个 bucket 只要有节点存在就至少含一个联系人"这条不变量下:设距目标最近的节点深度为
不变量的维持:刷新之后,一张 bucket 要么含
破坏不变量的唯一方式,是某张 bucket 的范围里存在
个或更多节点、而 bucket 里实际含有的那 个在没有任何中间查找或刷新的情况下全部失败。 而 正是按"一小时内(最大刷新时间)同时失败的概率很小"来选的。
实际概率比按时间算出来的更小,理由是前面提过的对称性(收发请求都在更新 bucket)。另外两条兜底:即使某张 bucket 的不变量真的破了,也只影响运行时间(给某些查找加一跳),不影响 node lookup 的正确性;要让一次查找失败,路径上的
(key,value) 的恢复:发布时落在
边界也很清楚:若某个 key 的
与前几篇的关系
对 Chord —— Chord 的 finger table 每一项必须是一个精确节点(区间之前的那一个),而 Kademlia 可以选区间内任意节点,于是 Kademlia 能基于延迟选路、能并行发查询,而 Chord 的路由表是"僵硬"的。另一处差异在方向:Chord 用环上的顺时针距离(不对称),XOR 既单向又对称。Chord 的节点不会从收到的查询里学到路由信息,而 Kademlia 因为对称性每次收发都在更新 bucket。
对 Pastry —— Kademlia 最像 Pastry 的第一阶段(按 XOR 度量连续把距离减半),但 Pastry 在第二阶段换成了数值差度量,并在复制时也用它;而两套度量之间的不连续会在特定 ID 值处伤害性能、并使最坏情况分析复杂化。而 Kademlia 从开始到结束只用一个度量、一个算法。另一处对比在
对 CAN —— CAN 用几何坐标空间里的直线,Kademlia 用二进制树;两者的共同点是路由决策完全由 ID 决定、不需要按名称查表,区别在 CAN 要维持几何邻居关系而 Kademlia 的 bucket 是按距离区间分层维护、且能容忍区间内任选。
相关
- Pastry —— 关联最紧的一篇:Kademlia 最像 Pastry 的第一阶段,而 Pastry 的第二阶段换度量正是 Kademlia 批评的那个设计点(不连续 / 分析复杂)。另一处是
推广时 Pastry 需要额外次级表 - Chord —— 另一处亲口对照:Chord 的 finger 项必须是精确的"区间前一个节点" → 路由表僵硬、不能并行发查询;且 Chord 节点不会从查询中学到路由信息。两者都用 160 位 ID 与
状态,但度量选择(数值差 vs XOR)决定了能否"区间内任选" - CAN —— 第四种"位置算得出来"的路线;CAN 靠几何坐标与邻居关系,Kademlia 靠 XOR 与 bucket 区间
- 一致性哈希算法 —— 本专栏的前置工具。注意 Kademlia 的 node ID "目前只是随机 160 位标识符,不过也可以像 Chord 那样构造" —— 它并不依赖环,所以不需要虚拟节点那种"为均匀性打补丁"的机制,均匀性由随机 ID 与 bucket 分裂规则自然保证
- Dynamo —— 同样用"随机 ID + 就近"的思路,但 Dynamo 用环 + 虚拟节点 + 偏好列表,Kademlia 用二叉树 + bucket + 后缀缓存;两者都在处理"相邻 ID 的节点在地理上分散"这个由随机分配带来的后果,手段完全不同
- Ceph —— CRUSH 与 Kademlia 都属"纯函数式的位置计算",但 CRUSH 需要一张分层的 cluster map 来描述故障域,而 Kademlia 的 bucket 结构本身就是 map、且由协议自动生长
参考
- P. Maymounkov, D. Mazières. Kademlia: A Peer-to-Peer Information System Based on the XOR Metric. IPTPS 2002, LNCS 2429.
YJ